From: Stephane Bisson Date: Wed, 6 Sep 2017 10:28:05 +0000 (-0400) Subject: RCLFilters: convert related changes tool to new UX X-Git-Tag: 1.31.0-rc.0~1293^2 X-Git-Url: http://git.cyclocoop.org/%27%20.%20%24prefix%20.%20Wiki::transformTitleToURI%28%24matches%5B1%5D%29%20.%20%27?a=commitdiff_plain;h=2a320851bf0950f54ca6fe742d25bc2126e3b256;p=lhc%2Fweb%2Fwiklou.git RCLFilters: convert related changes tool to new UX Bug: T172161 Change-Id: I96af7ba583d03e6ff9833ac3b5f4b80cfd0ee626 --- diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 9b1e04a0ad..bea009b459 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1481,6 +1481,11 @@ "rcfilters-watchlist-showupdated": "Changes to pages you haven't visited since the changes occurred are in bold, with solid markers.", "rcfilters-preference-label": "Hide the improved version of Recent Changes", "rcfilters-preference-help": "Rolls back the 2017 interface redesign and all tools added then and since.", + "rcfilters-filter-showlinkedfrom-label": "Show changes on pages linked from:", + "rcfilters-filter-showlinkedfrom-option-label": "Show changes on pages linked FROM a page", + "rcfilters-filter-showlinkedto-label": "Show changes on pages linked to:", + "rcfilters-filter-showlinkedto-option-label": "Show changes on pages linked TO a page", + "rcfilters-target-page-placeholder": "Select a page", "rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since $3, $4 (up to $1 shown).", "rclistfromreset": "Reset date selection", "rclistfrom": "Show new changes starting from $2, $3", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 60cbed1f8e..2e09de52c6 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1676,6 +1676,11 @@ "rcfilters-watchlist-showupdated": "Message at the top of [[Special:Watchlist]] when the Structured filters are enabled that describes what unseen changes look like.\n\nCf. {{msg-mw|wlheader-showupdated}}", "rcfilters-preference-label": "Option in RecentChanges tab of [[Special:Preferences]].", "rcfilters-preference-help": "Explanation for the option in the RecentChanges tab of [[Special:Preferences]].", + "rcfilters-filter-showlinkedfrom-label": "Label that indicates that the page is showing changes that link FROM the target page. Used on [[Special:Recentchangeslinked]] when structured filters are enabled.", + "rcfilters-filter-showlinkedfrom-option-label": "Menu option to show changes FROM the target page. Used on [[Special:Recentchangeslinked]] when structured filters are enabled.", + "rcfilters-filter-showlinkedto-label": "Label that indicates that the page is showing changes that link TO the target page. Used on [[Special:Recentchangeslinked]] when structured filters are enabled.", + "rcfilters-filter-showlinkedto-option-label": "Menu option to show changes TO the target page. Used on [[Special:Recentchangeslinked]] when structured filters are enabled.", + "rcfilters-target-page-placeholder": "Placeholder text for the title lookup [[Special:Recentchangeslinked]] when structured filters are enabled.", "rcnotefrom": "This message is displayed at [[Special:RecentChanges]] when viewing recentchanges from some specific time.\n\nThe corresponding message is {{msg-mw|Rclistfrom}}.\n\nParameters:\n* $1 - the maximum number of changes that are displayed\n* $2 - (Optional) a date and time\n* $3 - a date\n* $4 - a time\n* $5 - Number of changes are displayed, for use with PLURAL", "rclistfromreset": "Used on [[Special:RecentChanges]] to reset a selection of a certain date range.", "rclistfrom": "Used on [[Special:RecentChanges]]. Parameters:\n* $1 - (Currently not use) date and time. The date and the time adds to the rclistfrom description.\n* $2 - time. The time adds to the rclistfrom link description (with split of date and time).\n* $3 - date. The date adds to the rclistfrom link description (with split of date and time).\n\nThe corresponding message is {{msg-mw|Rcnotefrom}}.", diff --git a/resources/Resources.php b/resources/Resources.php index 0665a2ad2a..6b55ef9e5b 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1807,6 +1807,9 @@ return [ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js', 'resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js', 'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js', @@ -1836,6 +1839,7 @@ return [ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RcTopSectionWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less', ], 'skinStyles' => [ @@ -1906,6 +1910,11 @@ return [ 'rcfilters-watchlist-markseen-button', 'rcfilters-watchlist-edit-watchlist-button', 'rcfilters-other-review-tools', + 'rcfilters-filter-showlinkedfrom-label', + 'rcfilters-filter-showlinkedfrom-option-label', + 'rcfilters-filter-showlinkedto-label', + 'rcfilters-filter-showlinkedto-option-label', + 'rcfilters-target-page-placeholder', 'blanknamespace', 'namespaces', 'tags-title', @@ -1921,6 +1930,7 @@ return [ 'mediawiki.language', 'mediawiki.user', 'mediawiki.util', + 'mediawiki.widgets', 'mediawiki.rcfilters.filters.dm', 'oojs-ui.styles.icons-content', 'oojs-ui.styles.icons-moderation', diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js index c6eb635186..1950b935c4 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js @@ -150,6 +150,8 @@ // For this group type, parameter values are direct // We need to convert from a boolean to a string ('1' and '0') model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) ); + } else if ( model.getType() === 'any_value' ) { + model.defaultParams[ filter.name ] = filter.default; } } ); @@ -578,7 +580,7 @@ if ( buildFromCurrentState ) { // This means we have not been given a filter representation // so we are building one based on current state - filterRepresentation[ item.getName() ] = item.isSelected(); + filterRepresentation[ item.getName() ] = item.getValue(); } else if ( filterRepresentation[ item.getName() ] === undefined ) { // We are given a filter representation, but we have to make // sure that we fill in the missing filters if there are any @@ -598,7 +600,8 @@ // Build result if ( this.getType() === 'send_unselected_if_any' || - this.getType() === 'boolean' + this.getType() === 'boolean' || + this.getType() === 'any_value' ) { // First, check if any of the items are selected at all. // If none is selected, we're treating it as if they are @@ -615,6 +618,8 @@ // Representation is straight-forward and direct from // the parameter value to the filter state result[ filterParamNames[ name ] ] = String( Number( !!value ) ); + } else if ( model.getType() === 'any_value' ) { + result[ filterParamNames[ name ] ] = value; } } ); } else if ( this.getType() === 'string_options' ) { @@ -665,7 +670,8 @@ paramRepresentation = paramRepresentation || {}; if ( this.getType() === 'send_unselected_if_any' || - this.getType() === 'boolean' + this.getType() === 'boolean' || + this.getType() === 'any_value' ) { // Go over param representation; map and check for selections this.getItems().forEach( function ( filterItem ) { @@ -694,6 +700,8 @@ } else if ( model.getType() === 'boolean' ) { // Straight-forward definition of state result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] ); + } else if ( model.getType() === 'any_value' ) { + result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ]; } } ); } else if ( this.getType() === 'string_options' ) { @@ -738,9 +746,9 @@ // If any filters are missing, they will get a falsey value this.getItems().forEach( function ( filterItem ) { if ( result[ filterItem.getName() ] === undefined ) { - result[ filterItem.getName() ] = false; + result[ filterItem.getName() ] = this.getFalsyValue(); } - } ); + }.bind( this ) ); // Make sure that at least one option is selected in // single_option groups, no matter what path was taken @@ -762,6 +770,13 @@ return result; }; + /** + * @return {*} The appropriate falsy value for this group type + */ + mw.rcfilters.dm.FilterGroup.prototype.getFalsyValue = function () { + return this.getType() === 'any_value' ? '' : false; + }; + /** * Get current selected state of all filter items in this group * diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js index 4acbc55277..8d22c23e1a 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -385,7 +385,8 @@ $.each( this.groups, function ( group, groupModel ) { if ( groupModel.getType() === 'send_unselected_if_any' || - groupModel.getType() === 'boolean' + groupModel.getType() === 'boolean' || + groupModel.getType() === 'any_value' ) { // Individual filters groupModel.getItems().forEach( function ( filterItem ) { @@ -414,18 +415,18 @@ * @param {Object} params Parameters object */ mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) { + var filtersValue; // For arbitrary numeric single_option values make sure the values // are normalized to fit within the limits $.each( this.getFilterGroups(), function ( groupName, groupModel ) { params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] ); } ); - // Update filter states - this.toggleFiltersSelected( - this.getFiltersFromParameters( - params - ) - ); + // Update filter values + filtersValue = this.getFiltersFromParameters( params ); + Object.keys( filtersValue ).forEach( function ( filterName ) { + this.getItemByName( filterName ).setValue( filtersValue[ filterName ] ); + }.bind( this ) ); // Update highlight state this.getItemsSupportingHighlights().forEach( function ( filterItem ) { @@ -619,7 +620,7 @@ /** * Get the current selected state of the filters * - * @param {boolean} onlySelected return an object containing only the selected filters + * @param {boolean} [onlySelected] return an object containing only the filters with a value * @return {Object} Filters selected state */ mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) { @@ -628,8 +629,8 @@ result = {}; for ( i = 0; i < items.length; i++ ) { - if ( !onlySelected || items[ i ].isSelected() ) { - result[ items[ i ].getName() ] = items[ i ].isSelected(); + if ( !onlySelected || items[ i ].getValue() ) { + result[ items[ i ].getName() ] = items[ i ].getValue(); } } @@ -739,7 +740,7 @@ // all filters (set to false) this.getItems().forEach( function ( filterItem ) { groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {}; - groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = !!filterDefinition[ filterItem.getName() ]; + groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] ); } ); } diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js index 44b6c8cff5..d1e40cad90 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js @@ -14,6 +14,7 @@ * with 'default' and 'inverted' as keys. * @cfg {boolean} [active=true] The filter is active and affecting the result * @cfg {boolean} [selected] The item is selected + * @cfg {*} [value] The value of this item * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique * identifier * @cfg {string} [cssClass] The class identifying the results that match this filter @@ -34,7 +35,7 @@ this.label = config.label || this.name; this.labelPrefixKey = config.labelPrefixKey; this.description = config.description || ''; - this.selected = !!config.selected; + this.setValue( config.value || config.selected ); this.identifiers = config.identifiers || []; @@ -151,7 +152,7 @@ * @return {boolean} Filter is selected */ mw.rcfilters.dm.ItemModel.prototype.isSelected = function () { - return this.selected; + return !!this.value; }; /** @@ -161,10 +162,38 @@ * @fires update */ mw.rcfilters.dm.ItemModel.prototype.toggleSelected = function ( isSelected ) { - isSelected = isSelected === undefined ? !this.selected : isSelected; + isSelected = isSelected === undefined ? !this.isSelected() : isSelected; + this.setValue( isSelected ); + }; + + /** + * Get the value + * + * @return {*} + */ + mw.rcfilters.dm.ItemModel.prototype.getValue = function () { + return this.value; + }; + + /** + * Convert a given value to the appropriate representation based on group type + * + * @param {*} value + * @return {*} + */ + mw.rcfilters.dm.ItemModel.prototype.coerceValue = function ( value ) { + return this.getGroupModel().getType() === 'any_value' ? value : !!value; + }; - if ( this.selected !== isSelected ) { - this.selected = isSelected; + /** + * Set the value + * + * @param {*} newValue + */ + mw.rcfilters.dm.ItemModel.prototype.setValue = function ( newValue ) { + newValue = this.coerceValue( newValue ); + if ( this.value !== newValue ) { + this.value = newValue; this.emit( 'update' ); } }; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index 0bb6acfc70..ba54755b89 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -185,6 +185,37 @@ ] }; + views.recentChangesLinked = { + groups: [ + { + name: 'page', + type: 'any_value', + title: '', + hidden: true, + isSticky: false, + filters: [ + { + name: 'target', + 'default': '' + } + ] + }, + { + name: 'toOrFrom', + type: 'boolean', + title: '', + hidden: true, + isSticky: false, + filters: [ + { + name: 'showlinkedto', + 'default': false + } + ] + } + ] + }; + // Before we do anything, we need to see if we require additional items in the // groups that have 'AllowArbitrary'. For the moment, those are only single_option // groups; if we ever expand it, this might need further generalization: @@ -520,6 +551,33 @@ } }; + /** + * Set the value of the 'showlinkedto' parameter + * @param {boolean} value + */ + mw.rcfilters.Controller.prototype.setShowLinkedTo = function ( value ) { + var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ), + showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ); + + this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value ); + this.uriProcessor.updateURL(); + // reload the results only when target is set + if ( targetItem.getValue() ) { + this.updateChangesList(); + } + }; + + /** + * Set the target page + * @param {string} page + */ + mw.rcfilters.Controller.prototype.setTargetPage = function ( page ) { + var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ); + targetItem.setValue( page ); + this.uriProcessor.updateURL(); + this.updateChangesList(); + }; + /** * Set the highlight color for a filter item * @@ -850,7 +908,7 @@ mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) { fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList; - this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query ); + this.uriProcessor.updateModelBasedOnQuery(); // Update the sticky preferences, in case we received a value // from the URL @@ -1000,10 +1058,11 @@ }; } - $parsed = $( '
' ).append( $( $.parseHTML( data.content ) ) ); + $parsed = $( '
' ).append( $( $.parseHTML( + data ? data.content : '' + ) ) ); return this._extractChangesListInfo( $parsed ); - }.bind( this ) ); }; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js index 0392f34225..3e1191f392 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js @@ -57,38 +57,20 @@ /** * Get an updated mw.Uri object based on the model state * - * @param {Object} [uriQuery] An external URI query to build the new uri - * with. This is mainly for tests, to be able to supply external parameters - * and make sure they are retained. + * @param {mw.Uri} [uri] An external URI to build the new uri + * with. This is mainly for tests, to be able to supply external query + * parameters and make sure they are retained. * @return {mw.Uri} Updated Uri */ - mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uriQuery ) { - var titlePieces, - uri = new mw.Uri(), - unrecognizedParams = this.getUnrecognizedParams( uriQuery || uri.query ); + mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uri ) { + var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ), + unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query ); - if ( uriQuery ) { - // This is mainly for tests, to be able to give the method - // an initial URI Query and test that it retains parameters - uri.query = uriQuery; - } - - // Normalize subpage to use &target= so we are always - // consistent in Special:RecentChangesLinked between the - // ?title=Special:RecentChangesLinked/TargetPage and - // ?title=Special:RecentChangesLinked&target=TargetPage - if ( uri.query.title && uri.query.title.indexOf( '/' ) !== -1 ) { - titlePieces = uri.query.title.split( '/' ); - - unrecognizedParams.title = titlePieces.shift(); - unrecognizedParams.target = titlePieces.join( '/' ); - } - - uri.query = this.filtersModel.getMinimizedParamRepresentation( + normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {}, - uri.query, + normalizedUri.query, // The representation must be expanded so it can // override the uri query params but we then output // a minimized version for the entire URI representation @@ -98,7 +80,44 @@ ); // Reapply unrecognized params and url version - uri.query = $.extend( true, {}, uri.query, unrecognizedParams, { urlversion: '2' } ); + normalizedUri.query = $.extend( + true, + {}, + normalizedUri.query, + unrecognizedParams, + { urlversion: '2' } + ); + + return normalizedUri; + }; + + /** + * Move the subpage to the target parameter + * + * @param {mw.Uri} uri + * @return {mw.Uri} + * @private + */ + mw.rcfilters.UriProcessor.prototype._normalizeTargetInUri = function ( uri ) { + var parts, + re = /^((?:\/.+\/)?.+:.+)\/(.+)$/; // matches [namespace:]Title/Subpage + + // target in title param + if ( uri.query.title ) { + parts = uri.query.title.match( re ); + if ( parts ) { + uri.query.title = parts[ 1 ]; + uri.query.target = parts[ 2 ]; + } + } + + // target in path + parts = uri.path.match( re ); + if ( parts ) { + uri.path = parts[ 1 ]; + uri.query.target = parts[ 2 ]; + } + return uri; }; @@ -154,15 +173,16 @@ * we consider the system synchronized, and the model serves * as the source of truth for the URL. * - * This methods should only be called once on initialiation. + * This methods should only be called once on initialization. * After initialization, the model updates the URL, not the * other way around. * * @param {Object} [uriQuery] URI query */ mw.rcfilters.UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) { + uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query; this.filtersModel.updateStateFromParams( - this._getNormalizedQueryParams( uriQuery || new mw.Uri().query ) + this._getNormalizedQueryParams( uriQuery ) ); }; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 10bbcf6b90..6ec120006a 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -9,9 +9,8 @@ */ init: function () { var $topLinks, - rcTopSection, + topSection, $watchlistDetails, - wlTopSection, namespaces, savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ), daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ), @@ -75,25 +74,33 @@ controller.replaceUrl(); - if ( specialPage === 'Recentchanges' || - specialPage === 'Recentchangeslinked' ) { + if ( specialPage === 'Recentchanges' ) { $topLinks = $( '.mw-recentchanges-toplinks' ).detach(); - rcTopSection = new mw.rcfilters.ui.RcTopSectionWidget( + topSection = new mw.rcfilters.ui.RcTopSectionWidget( savedLinksListWidget, $topLinks ); - filtersWidget.setTopSection( rcTopSection.$element ); - } // end Special:RC + filtersWidget.setTopSection( topSection.$element ); + } // end Recentchanges + + if ( specialPage === 'Recentchangeslinked' ) { + topSection = new mw.rcfilters.ui.RclTopSectionWidget( + savedLinksListWidget, controller, + filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ), + filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ) + ); + filtersWidget.setTopSection( topSection.$element ); + } // end Recentchangeslinked if ( specialPage === 'Watchlist' ) { $( '#contentSub, form#mw-watchlist-resetbutton' ).detach(); $watchlistDetails = $( '.watchlistDetails' ).detach().contents(); - wlTopSection = new mw.rcfilters.ui.WatchlistTopSectionWidget( + topSection = new mw.rcfilters.ui.WatchlistTopSectionWidget( controller, changesListModel, savedLinksListWidget, $watchlistDetails ); - filtersWidget.setTopSection( wlTopSection.$element ); - } // end Special:WL + filtersWidget.setTopSection( topSection.$element ); + } // end Watchlist /** * Fired when initialization of the filtering interface for changes list is complete. diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less new file mode 100644 index 0000000000..577c254851 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less @@ -0,0 +1,11 @@ +.mw-rcfilters-ui-rclToOrFromWidget { + min-width: 340px; + + // need to be very specific to override bg-color + &.oo-ui-dropdownWidget.oo-ui-widget-enabled { + .oo-ui-dropdownWidget-handle { + border: 0; + background-color: transparent; + } + } +} diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js index 6aa335a58a..237a635284 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js @@ -133,6 +133,9 @@ this.$element.find( '.namespaceForm' ).detach(); this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach(); + // Hide Related Changes page name form + this.$element.find( '.targetForm' ).detach(); + // misc: limit, days, watchlist info msg this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach(); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js new file mode 100644 index 0000000000..d14681bc2b --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js @@ -0,0 +1,73 @@ +( function ( mw ) { + /** + * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes) + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.Controller} controller + * @param {mw.rcfilters.dm.FilterItem} targetPageModel + * @param {Object} [config] Configuration object + */ + mw.rcfilters.ui.RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget( + controller, targetPageModel, config + ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.RclTargetPageWidget.parent.call( this, config ); + + this.controller = controller; + this.model = targetPageModel; + + this.titleSearch = new mw.widgets.TitleInputWidget( { + validate: false, + placeholder: mw.msg( 'rcfilters-target-page-placeholder' ) + } ); + + // Events + this.model.connect( this, { update: 'updateUiBasedOnModel' } ); + + this.titleSearch.$input.on( { + blur: this.onLookupInputBlur.bind( this ) + } ); + + this.titleSearch.lookupMenu.connect( this, { + choose: 'onLookupMenuItemChoose' + } ); + + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' ) + .append( this.titleSearch.$element ); + + this.updateUiBasedOnModel(); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.RclTargetPageWidget, OO.ui.Widget ); + + /* Methods */ + + /** + * Respond to the user choosing a title + */ + mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () { + this.titleSearch.$input.blur(); + }; + + /** + * Respond to titleSearch $input blur + */ + mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupInputBlur = function () { + this.controller.setTargetPage( this.titleSearch.getQueryValue() ); + }; + + /** + * Respond to the model being updated + */ + mw.rcfilters.ui.RclTargetPageWidget.prototype.updateUiBasedOnModel = function () { + this.titleSearch.setValue( this.model.getValue() ); + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js new file mode 100644 index 0000000000..e91fe9b370 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js @@ -0,0 +1,73 @@ +( function ( mw ) { + /** + * Widget to select to view changes that link TO or FROM the target page + * on Special:RecentChangesLinked (AKA Related Changes) + * + * @extends OO.ui.DropdownWidget + * + * @constructor + * @param {mw.rcfilters.Controller} controller + * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to + * @param {Object} [config] Configuration object + */ + mw.rcfilters.ui.RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget( + controller, showLinkedToModel, config + ) { + config = config || {}; + + this.showLinkedFrom = new OO.ui.MenuOptionWidget( { + data: 'from', // default (showlinkedto=0) + label: mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) + } ); + this.showLinkedTo = new OO.ui.MenuOptionWidget( { + data: 'to', // showlinkedto=1 + label: mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) + } ); + + // Parent + mw.rcfilters.ui.RclToOrFromWidget.parent.call( this, $.extend( { + classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ], + menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] } + }, config ) ); + + this.controller = controller; + this.model = showLinkedToModel; + + this.getMenu().connect( this, { choose: 'onUserChooseItem' } ); + this.model.connect( this, { update: 'onModelUpdate' } ); + + // force an initial update of the component based on the state + this.onModelUpdate(); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.RclToOrFromWidget, OO.ui.DropdownWidget ); + + /* Methods */ + + /** + * Respond to the user choosing an item in the menu + * + * @param {OO.ui.MenuOptionWidget} chosenItem + */ + mw.rcfilters.ui.RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) { + this.controller.setShowLinkedTo( chosenItem.getData() === 'to' ); + }; + + /** + * Respond to model update + */ + mw.rcfilters.ui.RclToOrFromWidget.prototype.onModelUpdate = function () { + this.getMenu().selectItem( + this.model.isSelected() ? + this.showLinkedTo : + this.showLinkedFrom + ); + this.setLabel( mw.msg( + this.model.isSelected() ? + 'rcfilters-filter-showlinkedto-label' : + 'rcfilters-filter-showlinkedfrom-label' + ) ); + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js new file mode 100644 index 0000000000..2fdf365fac --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js @@ -0,0 +1,66 @@ +( function ( mw ) { + /** + * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges) + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget + * @param {mw.rcfilters.Controller} controller + * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter + * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter + * @param {Object} [config] Configuration object + */ + mw.rcfilters.ui.RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget( + savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config + ) { + var toOrFromWidget, + targetPage; + config = config || {}; + + // Parent + mw.rcfilters.ui.RclTopSectionWidget.parent.call( this, config ); + + this.controller = controller; + + toOrFromWidget = new mw.rcfilters.ui.RclToOrFromWidget( controller, showLinkedToModel ); + targetPage = new mw.rcfilters.ui.RclTargetPageWidget( controller, targetPageModel ); + + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( toOrFromWidget.$element ) + ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( targetPage.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-table-placeholder' ) + .addClass( 'mw-rcfilters-ui-cell' ), + !mw.user.isAnon() ? + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' ) + .append( savedLinksListWidget.$element ) : + null + ) + ) + ); + }; + + /* Initialization */ + + OO.inheritClass( mw.rcfilters.ui.RclTopSectionWidget, OO.ui.Widget ); +}( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js index e106b121f9..674bf07295 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js @@ -60,19 +60,24 @@ QUnit.test( 'getUpdatedUri', function ( assert ) { var uriProcessor, - filtersModel = new mw.rcfilters.dm.FiltersViewModel(); + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + makeUri = function ( queryParams ) { + var uri = new mw.Uri(); + uri.query = queryParams; + return uri; + }; filtersModel.initializeFilters( mockFilterStructure ); uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); assert.deepEqual( - ( uriProcessor.getUpdatedUri( {} ) ).query, + ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query, { urlversion: '2' }, 'Empty model state with empty uri state, assumes the given uri is already normalized, and adds urlversion=2' ); assert.deepEqual( - ( uriProcessor.getUpdatedUri( { foo: 'bar' } ) ).query, + ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) ) ).query, { urlversion: '2', foo: 'bar' }, 'Empty model state with unrecognized params retains unrecognized params' ); @@ -84,13 +89,13 @@ } ); assert.deepEqual( - ( uriProcessor.getUpdatedUri( {} ) ).query, + ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query, { urlversion: '2', filter2: '1', group3: 'filter5' }, 'Model state is reflected in the updated URI' ); assert.deepEqual( - ( uriProcessor.getUpdatedUri( { foo: 'bar' } ) ).query, + ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) ) ).query, { urlversion: '2', filter2: '1', group3: 'filter5', foo: 'bar' }, 'Model state is reflected in the updated URI with existing uri params' ); @@ -272,4 +277,38 @@ } ); } ); + QUnit.test( '_normalizeTargetInUri', function ( assert ) { + var uriProcessor = new mw.rcfilters.UriProcessor( null ), + cases = [ + { + input: 'http://host/wiki/Special:RecentChangesLinked/Moai', + output: 'http://host/wiki/Special:RecentChangesLinked?target=Moai', + message: 'Target as subpage in path' + }, + { + input: 'http://host/wiki/Special:RecentChangesLinked/Category:Foo', + output: 'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo', + message: 'Target as subpage in path (with namespace)' + }, + { + input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Moai', + output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai', + message: 'Target as subpage in title param' + }, + { + input: 'http://host/wiki/Special:Watchlist', + output: 'http://host/wiki/Special:Watchlist', + message: 'No target specified' + } + ]; + + cases.forEach( function ( testCase ) { + assert.equal( + uriProcessor._normalizeTargetInUri( new mw.Uri( testCase.input ) ).toString(), + new mw.Uri( testCase.output ).toString(), + testCase.message + ); + } ); + } ); + }( mediaWiki, jQuery ) ); diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js index 271648f57a..18a2c9cebe 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js @@ -184,4 +184,22 @@ 'Events emitted successfully.' ); } ); + + QUnit.test( 'get/set boolean value', function ( assert ) { + var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 'boolean' } ), + item = new mw.rcfilters.dm.FilterItem( 'filter1', group ); + + item.setValue( '1' ); + + assert.equal( item.getValue(), true, 'Value is coerced to boolean' ); + } ); + + QUnit.test( 'get/set any value', function ( assert ) { + var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 'any_value' } ), + item = new mw.rcfilters.dm.FilterItem( 'filter1', group ); + + item.setValue( '1' ); + + assert.equal( item.getValue(), '1', 'Value is kept as-is' ); + } ); }( mediaWiki ) );